[secondModel].tsx 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138
  1. import "../../../../index.css"
  2. import { Link, Meta, Title } from "@solidjs/meta"
  3. import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
  4. import {
  5. getStatsModelsComparisonData,
  6. type ModelUsagePoint,
  7. type StatsModelComparisonInput,
  8. type StatsModelComparisonEntry,
  9. } from "@opencode-ai/stats-core/domain/home"
  10. import { runtime } from "@opencode-ai/stats-core/runtime"
  11. import { createAsync, query, useParams, useSearchParams } from "@solidjs/router"
  12. import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
  13. import { getRequestEvent } from "solid-js/web"
  14. import {
  15. ComparisonCardsSection,
  16. comparisonHref,
  17. modelRefFromCatalog,
  18. uniqueComparisonPairs,
  19. type ComparisonModelRef,
  20. type ComparisonPair,
  21. } from "../../../../compare-cards"
  22. import {
  23. catalogSlug,
  24. findModelCatalogEntry,
  25. formatCatalogLabName,
  26. getModelCatalog,
  27. type ModelCatalog,
  28. type ModelCatalogEntry,
  29. } from "../../../../model-catalog"
  30. import {
  31. applyThemePreference,
  32. Footer,
  33. getGitHubStars,
  34. Header,
  35. isThemePreference,
  36. themeStorageKey,
  37. type HeaderLink,
  38. type ThemePreference,
  39. } from "../../../../stats-shell"
  40. const compareFallbackUrl = "https://stats.opencode.ai"
  41. const compareHeaderLinks: readonly HeaderLink[] = [
  42. { href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" },
  43. { href: `${import.meta.env.BASE_URL}#leaderboard`, label: "Leaderboard" },
  44. { href: `${import.meta.env.BASE_URL}#market-share`, label: "Market Share" },
  45. { href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" },
  46. { href: `${import.meta.env.BASE_URL}#session-cost`, label: "Session Cost" },
  47. ]
  48. const compareFooterLinks: readonly HeaderLink[] = [
  49. { href: import.meta.env.BASE_URL, label: "Data Home" },
  50. { href: `${import.meta.env.BASE_URL}compare`, label: "Model Compare" },
  51. { href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" },
  52. { href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" },
  53. ]
  54. const heroLabs = [
  55. { lab: "deepseek", label: "DeepSeek" },
  56. { lab: "openai", label: "OpenAI" },
  57. { lab: "anthropic", label: "Anthropic" },
  58. ] as const
  59. const usageBarLimit = 60
  60. const comparisonModelLimit = 6
  61. type ComparisonModel = {
  62. name: string
  63. lab: string
  64. labName: string
  65. slug: string
  66. catalog: ModelCatalogEntry | null
  67. stats: StatsModelComparisonEntry | null
  68. }
  69. type ComparisonDirection = "higher" | "lower"
  70. type ComparisonDetailCell = {
  71. value: string
  72. unit?: string
  73. href?: string
  74. kind?: "boolean"
  75. score?: number
  76. trend?: number
  77. }
  78. type ComparisonDetailRow = {
  79. label: string
  80. direction?: ComparisonDirection
  81. cells: ComparisonDetailCell[]
  82. }
  83. type ComparisonDetailSection = {
  84. title: string
  85. badge?: string
  86. rows: ComparisonDetailRow[]
  87. usage?: ModelUsagePoint[][]
  88. }
  89. type ComparisonModelSelection = {
  90. lab: string
  91. slug: string
  92. catalog: ModelCatalogEntry | null | undefined
  93. }
  94. type ComparisonModels = [ComparisonModel, ComparisonModel, ...ComparisonModel[]]
  95. const getComparisonData = query(async (models: StatsModelComparisonInput[]) => {
  96. "use server"
  97. return runtime.runPromise(getStatsModelsComparisonData(models))
  98. }, "getStatsModelComparisonDetailData")
  99. export default function ModelComparePair() {
  100. const event = getRequestEvent()
  101. event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
  102. const params = useParams()
  103. const [searchParams] = useSearchParams<{ add?: string }>()
  104. const firstLabParam = createMemo(() => params.firstLab ?? "")
  105. const firstModelParam = createMemo(() => params.firstModel ?? "")
  106. const secondLabParam = createMemo(() => params.secondLab ?? "")
  107. const secondModelParam = createMemo(() => params.secondModel ?? "")
  108. const catalog = createAsync(() => getModelCatalog())
  109. const firstCatalog = createMemo(() => resolvedCatalogEntry(catalog(), firstLabParam(), firstModelParam()))
  110. const secondCatalog = createMemo(() => resolvedCatalogEntry(catalog(), secondLabParam(), secondModelParam()))
  111. const modelSelections = createMemo(() => {
  112. const selected: ComparisonModelSelection[] = [
  113. { lab: firstLabParam(), slug: firstModelParam(), catalog: firstCatalog() },
  114. { lab: secondLabParam(), slug: secondModelParam(), catalog: secondCatalog() },
  115. ...parseAdditionalModels(searchParams.add).map((model) => ({
  116. ...model,
  117. catalog: resolvedCatalogEntry(catalog(), model.lab, model.slug),
  118. })),
  119. ]
  120. return selected
  121. .filter(
  122. (model, index) =>
  123. index < 2 ||
  124. selected.findIndex(
  125. (candidate) => comparisonModelSelectionKey(candidate) === comparisonModelSelectionKey(model),
  126. ) === index,
  127. )
  128. .slice(0, comparisonModelLimit) as [
  129. ComparisonModelSelection,
  130. ComparisonModelSelection,
  131. ...ComparisonModelSelection[],
  132. ]
  133. })
  134. const stats = createAsync(() => {
  135. const selected = modelSelections()
  136. if (catalog() === undefined || selected.some((model) => model.catalog === undefined))
  137. return Promise.resolve(undefined)
  138. return getComparisonData(
  139. selected.map((model) => ({
  140. provider: model.catalog?.lab ?? model.lab,
  141. model: model.catalog?.slug ?? model.slug,
  142. })),
  143. )
  144. })
  145. const githubStars = createAsync(() => getGitHubStars())
  146. const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
  147. const [highlightBest, setHighlightBest] = createSignal(true)
  148. const [addingModel, setAddingModel] = createSignal(false)
  149. const models = createMemo(
  150. () =>
  151. modelSelections().map((model, index) =>
  152. buildComparisonModel(model.lab, model.slug, model.catalog ?? null, stats()?.models[index] ?? null),
  153. ) as ComparisonModels,
  154. )
  155. const title = createMemo(() => `${models()[0].name} vs ${models()[1].name} - Model Comparison`)
  156. const description = createMemo(
  157. () =>
  158. `Compare ${models()[0].name} and ${models()[1].name} by usage, rank, context window, output limit, cache ratio, and cost across OpenCode data.`,
  159. )
  160. const canonicalPath = createMemo(
  161. () =>
  162. `${import.meta.env.BASE_URL}compare/${catalogSlug(models()[0].lab)}/${catalogSlug(models()[0].slug)}/${catalogSlug(
  163. models()[1].lab,
  164. )}/${catalogSlug(models()[1].slug)}`,
  165. )
  166. const canonicalUrl = createMemo(() =>
  167. new URL(
  168. canonicalPath(),
  169. event?.request.url ?? (typeof window === "undefined" ? compareFallbackUrl : window.location.href),
  170. ).toString(),
  171. )
  172. const detailSections = createMemo(() => buildComparisonDetailSections(models()))
  173. const relatedPairs = createMemo(() => buildRelatedPairs(catalog(), models()[0], models()[1]))
  174. const selectorModels = createMemo(() =>
  175. uniqueCatalogModels([...models().map(comparisonCatalogEntry), ...(catalog()?.models ?? [])]),
  176. )
  177. const selectedCatalogModels = createMemo(() => models().map(comparisonCatalogEntry))
  178. const canAddModel = createMemo(
  179. () =>
  180. models().length < comparisonModelLimit &&
  181. selectorModels().some((model) => !selectedCatalogModels().some((selected) => selected.id === model.id)),
  182. )
  183. const navigateToModels = (next: ModelCatalogEntry[]) => {
  184. if (typeof window === "undefined" || next.length < 2) return
  185. window.location.href = comparisonModelsHref(next)
  186. }
  187. const structuredData = createMemo(() =>
  188. JSON.stringify({
  189. "@context": "https://schema.org",
  190. "@type": "WebPage",
  191. name: title(),
  192. description: description(),
  193. url: canonicalUrl(),
  194. about: models().map((model) => ({
  195. "@type": "SoftwareApplication",
  196. name: model.name,
  197. applicationCategory: "AI model",
  198. provider: model.labName,
  199. })),
  200. }),
  201. )
  202. const updateThemePreference = (preference: ThemePreference) => {
  203. applyThemePreference(preference)
  204. setThemePreference(preference)
  205. if (typeof window === "undefined") return
  206. window.localStorage.setItem(themeStorageKey, preference)
  207. }
  208. onMount(() => {
  209. if (typeof window === "undefined") return
  210. const preference = window.localStorage.getItem(themeStorageKey)
  211. const nextPreference = isThemePreference(preference) ? preference : "system"
  212. applyThemePreference(nextPreference)
  213. setThemePreference(nextPreference)
  214. })
  215. return (
  216. <main data-page="stats" data-layout="compare-detail" data-theme={themePreference()}>
  217. <Title>{title()}</Title>
  218. <Meta name="description" content={description()} />
  219. <Meta name="robots" content={models().length > 2 ? "noindex,follow" : "index,follow"} />
  220. <Link rel="canonical" href={canonicalUrl()} />
  221. <Meta property="og:type" content="website" />
  222. <Meta property="og:site_name" content="OpenCode" />
  223. <Meta property="og:title" content={title()} />
  224. <Meta property="og:description" content={description()} />
  225. <Meta property="og:url" content={canonicalUrl()} />
  226. <Meta name="twitter:card" content="summary" />
  227. <Meta name="twitter:title" content={title()} />
  228. <Meta name="twitter:description" content={description()} />
  229. <script type="application/ld+json">{structuredData()}</script>
  230. <Header githubStars={githubStars() ?? "150K"} links={compareHeaderLinks} brandHref={import.meta.env.BASE_URL} />
  231. <div data-component="container">
  232. <div data-component="content">
  233. <ComparisonHero
  234. canAddModel={canAddModel()}
  235. highlightBest={highlightBest()}
  236. models={models()}
  237. onAddModel={() => setAddingModel(true)}
  238. onHighlightBestChange={() => setHighlightBest(!highlightBest())}
  239. />
  240. <Show when={addingModel()}>
  241. <CompareModelSelectModal
  242. blockedIds={selectedCatalogModels().map((model) => model.id)}
  243. label="Add model"
  244. models={selectorModels()}
  245. onClose={() => setAddingModel(false)}
  246. onSelect={(model) => {
  247. setAddingModel(false)
  248. navigateToModels([...selectedCatalogModels(), model])
  249. }}
  250. />
  251. </Show>
  252. <div
  253. data-component="compare-detail-table"
  254. data-model-count={models().length}
  255. style={`--compare-detail-grid: ${comparisonDetailGridTemplate(models().length)}`}
  256. >
  257. <div data-component="compare-detail-scroll">
  258. <ComparisonPairSelector catalogModels={selectorModels()} models={models()} />
  259. <Show
  260. when={stats() !== undefined}
  261. fallback={
  262. <section data-section="compare-detail-matrix">
  263. <div data-component="empty-state" data-compact="true">
  264. <strong>Loading comparison</strong>
  265. <p>Loading model stats.</p>
  266. </div>
  267. </section>
  268. }
  269. >
  270. <ComparisonDetailMatrix
  271. highlightBest={highlightBest()}
  272. modelCount={models().length}
  273. sections={detailSections()}
  274. />
  275. </Show>
  276. </div>
  277. </div>
  278. <ComparisonCardsSection
  279. pairs={relatedPairs()}
  280. title="Related comparisons"
  281. description="Other model pairs to check."
  282. variant="featured"
  283. />
  284. </div>
  285. <Footer
  286. themePreference={themePreference()}
  287. onThemePreferenceChange={updateThemePreference}
  288. links={compareFooterLinks}
  289. bridge={{ href: "#comparison", label: "MODEL COMPARISON" }}
  290. />
  291. </div>
  292. </main>
  293. )
  294. }
  295. function ComparisonHero(props: {
  296. models: readonly ComparisonModel[]
  297. canAddModel: boolean
  298. highlightBest: boolean
  299. onAddModel: () => void
  300. onHighlightBestChange: () => void
  301. }) {
  302. return (
  303. <section id="overview" data-section="compare-detail-hero">
  304. <nav data-component="compare-home-breadcrumb" aria-label="Breadcrumb">
  305. <a data-slot="compare-home-crumb" href={import.meta.env.BASE_URL}>
  306. Data
  307. </a>
  308. <span data-slot="compare-home-separator">/</span>
  309. <a data-slot="compare-home-crumb" href={`${import.meta.env.BASE_URL}compare`}>
  310. Compare
  311. </a>
  312. </nav>
  313. <div data-slot="compare-detail-hero-grid">
  314. <h1 aria-label={`Compare ${props.models.map((model) => model.name).join(", ")}`}>
  315. <span>Compare</span>
  316. <HeroModelStack />
  317. <span>AI models</span>
  318. </h1>
  319. <div data-slot="compare-detail-actions">
  320. <button
  321. type="button"
  322. data-slot="compare-detail-action"
  323. data-active={props.highlightBest ? "true" : undefined}
  324. aria-pressed={props.highlightBest}
  325. onClick={props.onHighlightBestChange}
  326. >
  327. <span data-slot="compare-detail-highlight-icon" aria-hidden="true">
  328. <i />
  329. <i />
  330. </span>
  331. <span>Highlight best</span>
  332. </button>
  333. <button
  334. type="button"
  335. data-slot="compare-detail-action"
  336. disabled={!props.canAddModel}
  337. aria-haspopup="dialog"
  338. onClick={props.onAddModel}
  339. >
  340. <span data-slot="compare-home-plus" aria-hidden="true">
  341. +
  342. </span>
  343. <span>Add model</span>
  344. </button>
  345. </div>
  346. </div>
  347. <div data-slot="compare-home-pattern" aria-hidden="true" />
  348. </section>
  349. )
  350. }
  351. function HeroModelStack() {
  352. return (
  353. <span data-slot="compare-home-avatar-stack" aria-hidden="true">
  354. <For each={heroLabs}>
  355. {(lab) => (
  356. <span data-slot="compare-home-avatar-frame">
  357. <LabLogo lab={lab.lab} label={lab.label} size="large" />
  358. </span>
  359. )}
  360. </For>
  361. </span>
  362. )
  363. }
  364. function ComparisonPairSelector(props: { catalogModels: ModelCatalogEntry[]; models: readonly ComparisonModel[] }) {
  365. const [activeIndex, setActiveIndex] = createSignal<number>()
  366. const selectedModels = createMemo(() => props.models.map(comparisonCatalogEntry))
  367. const activeSelected = createMemo(() => {
  368. const index = activeIndex()
  369. if (index === undefined) return undefined
  370. return selectedModels()[index]
  371. })
  372. const blockedIds = createMemo(() =>
  373. selectedModels()
  374. .filter((_, index) => index !== activeIndex())
  375. .map((model) => model.id),
  376. )
  377. const navigateToSelection = (index: number, model: ModelCatalogEntry) => {
  378. if (typeof window === "undefined") return
  379. window.location.href = comparisonModelsHref(
  380. selectedModels().map((selected, selectedIndex) => (selectedIndex === index ? model : selected)),
  381. )
  382. }
  383. return (
  384. <section data-section="compare-detail-selector" aria-label="Selected models">
  385. <div data-component="compare-detail-selector-grid">
  386. <div data-slot="compare-detail-selector-spacer" aria-hidden="true" />
  387. <For each={props.models}>
  388. {(model, index) => (
  389. <CompareDetailSelectButton
  390. model={model}
  391. label={`Model ${index() + 1}`}
  392. column={index()}
  393. last={index() === props.models.length - 1}
  394. expanded={activeIndex() === index()}
  395. onOpen={() => setActiveIndex(index())}
  396. />
  397. )}
  398. </For>
  399. </div>
  400. <Show when={activeSelected()}>
  401. {(selected) => (
  402. <CompareModelSelectModal
  403. blockedIds={blockedIds()}
  404. label={`Choose model ${(activeIndex() ?? 0) + 1}`}
  405. models={props.catalogModels}
  406. selected={selected()}
  407. onClose={() => setActiveIndex(undefined)}
  408. onSelect={(model) => {
  409. const index = activeIndex()
  410. if (index === undefined) return
  411. setActiveIndex(undefined)
  412. navigateToSelection(index, model)
  413. }}
  414. />
  415. )}
  416. </Show>
  417. </section>
  418. )
  419. }
  420. function CompareDetailSelectButton(props: {
  421. model: ComparisonModel
  422. label: string
  423. column: number
  424. last: boolean
  425. expanded: boolean
  426. onOpen: () => void
  427. }) {
  428. return (
  429. <button
  430. type="button"
  431. data-slot="compare-detail-select-model"
  432. data-column={props.column}
  433. data-last={props.last ? "true" : undefined}
  434. aria-label={props.label}
  435. aria-haspopup="dialog"
  436. aria-expanded={props.expanded}
  437. onClick={props.onOpen}
  438. >
  439. <LabLogo lab={props.model.lab} label={props.model.labName} size="small" />
  440. <span data-slot="compare-detail-select-name">{props.model.name}</span>
  441. <ChevronDownIcon />
  442. </button>
  443. )
  444. }
  445. function CompareModelSelectModal(props: {
  446. models: ModelCatalogEntry[]
  447. selected?: ModelCatalogEntry
  448. blockedIds: string[]
  449. label: string
  450. onClose: () => void
  451. onSelect: (model: ModelCatalogEntry) => void
  452. }) {
  453. let searchInput: HTMLInputElement | undefined
  454. const [search, setSearch] = createSignal("")
  455. const [previewId, setPreviewId] = createSignal(props.selected?.id ?? "")
  456. const availableModels = createMemo(() =>
  457. uniqueCatalogModels([...(props.selected ? [props.selected] : []), ...props.models]).filter(
  458. (model) => !props.blockedIds.includes(model.id),
  459. ),
  460. )
  461. const filteredModels = createMemo(() => {
  462. const terms = search().trim().toLowerCase().split(/\s+/).filter(Boolean)
  463. if (terms.length === 0) return availableModels()
  464. return availableModels().filter((model) => terms.every((term) => modelSearchText(model).includes(term)))
  465. })
  466. const preview = createMemo(
  467. () => filteredModels().find((model) => model.id === previewId()) ?? filteredModels()[0] ?? availableModels()[0],
  468. )
  469. createEffect(() => {
  470. const models = filteredModels()
  471. if (models.some((model) => model.id === previewId())) return
  472. const selected =
  473. props.selected && models.some((model) => model.id === props.selected?.id) ? props.selected.id : undefined
  474. setPreviewId(selected ?? models[0]?.id ?? "")
  475. })
  476. createEffect(() => {
  477. if (typeof window === "undefined") return
  478. window.requestAnimationFrame(() => searchInput?.focus())
  479. })
  480. return (
  481. <div
  482. data-component="compare-model-modal-scrim"
  483. role="presentation"
  484. onClick={props.onClose}
  485. onKeyDown={(event) => {
  486. if (event.key !== "Escape") return
  487. props.onClose()
  488. }}
  489. >
  490. <div
  491. data-component="compare-model-modal"
  492. role="dialog"
  493. aria-modal="true"
  494. aria-label={props.label}
  495. onClick={(event) => event.stopPropagation()}
  496. >
  497. <div data-slot="compare-model-modal-list">
  498. <label data-slot="compare-model-modal-search">
  499. <span data-slot="compare-model-modal-search-icon" aria-hidden="true" />
  500. <input
  501. ref={searchInput}
  502. value={search()}
  503. placeholder="Search models"
  504. aria-label="Search models"
  505. onInput={(event) => setSearch(event.currentTarget.value)}
  506. />
  507. </label>
  508. <div data-slot="compare-model-modal-results">
  509. <Show
  510. when={filteredModels().length > 0}
  511. fallback={
  512. <div data-slot="compare-model-modal-empty">
  513. <strong>No models found</strong>
  514. <span>Try another search.</span>
  515. </div>
  516. }
  517. >
  518. <For each={filteredModels()}>
  519. {(model) => (
  520. <button
  521. type="button"
  522. data-slot="compare-model-modal-row"
  523. data-active={preview()?.id === model.id ? "true" : undefined}
  524. onMouseMove={() => setPreviewId(model.id)}
  525. onFocus={() => setPreviewId(model.id)}
  526. onClick={() => props.onSelect(model)}
  527. >
  528. <span data-slot="compare-model-modal-row-main">
  529. <ModelAvatar model={model} size="tiny" />
  530. <span>{model.name}</span>
  531. </span>
  532. <Show when={isFreeModel(model)}>
  533. <span data-slot="compare-model-modal-badge">Free</span>
  534. </Show>
  535. </button>
  536. )}
  537. </For>
  538. </Show>
  539. </div>
  540. </div>
  541. <div data-slot="compare-model-modal-divider" aria-hidden="true" />
  542. <Show when={preview()}>{(model) => <CompareModelDetail model={model()} />}</Show>
  543. </div>
  544. </div>
  545. )
  546. }
  547. function CompareModelDetail(props: { model: ModelCatalogEntry }) {
  548. return (
  549. <aside data-slot="compare-model-modal-detail">
  550. <header data-slot="compare-model-modal-detail-header">
  551. <ModelAvatar model={props.model} size="small" />
  552. <strong>{props.model.name}</strong>
  553. </header>
  554. <div data-slot="compare-model-modal-description">
  555. <p>
  556. {props.model.description ??
  557. `${props.model.name} is an AI model from ${formatCatalogLabName(props.model.lab)}.`}
  558. </p>
  559. <span aria-hidden="true" />
  560. </div>
  561. <dl data-slot="compare-model-modal-facts">
  562. <CompareModelFact label="Release" value={formatCatalogDate(props.model.releaseDate)} />
  563. <CompareModelFact label="Context" value={formatCatalogLimit(props.model.limit?.context)} />
  564. <CompareModelFact label="Input" value={formatCatalogUnitPrice(props.model.cost?.input)} />
  565. <CompareModelFact label="Output" value={formatCatalogUnitPrice(props.model.cost?.output)} />
  566. <CompareModelFact label="URL" value={formatModelUrl(props.model)} href={modelHref(props.model)} />
  567. </dl>
  568. </aside>
  569. )
  570. }
  571. function CompareModelFact(props: { label: string; value: string; href?: string }) {
  572. return (
  573. <div data-slot="compare-model-modal-fact">
  574. <dt>{props.label}</dt>
  575. <dd>
  576. <Show when={props.href} fallback={props.value}>
  577. {(href) => <a href={href()}>{props.value}</a>}
  578. </Show>
  579. </dd>
  580. </div>
  581. )
  582. }
  583. function ComparisonDetailMatrix(props: {
  584. sections: ComparisonDetailSection[]
  585. highlightBest: boolean
  586. modelCount: number
  587. }) {
  588. return (
  589. <section id="comparison" data-section="compare-detail-matrix" aria-label="Model comparison">
  590. <div data-component="compare-detail-matrix">
  591. <For each={props.sections}>
  592. {(section) => (
  593. <section data-slot="compare-detail-group" aria-label={section.title}>
  594. <ComparisonDetailSpacer modelCount={props.modelCount} />
  595. <div data-slot="compare-detail-label" data-heading="true">
  596. <strong>{section.title}</strong>
  597. <Show when={section.badge}>{(badge) => <span>{badge()}</span>}</Show>
  598. </div>
  599. <For each={Array.from({ length: props.modelCount })}>
  600. {(_, index) => (
  601. <div
  602. data-slot="compare-detail-value"
  603. data-column={index()}
  604. data-last={index() === props.modelCount - 1 ? "true" : undefined}
  605. data-heading="true"
  606. aria-hidden="true"
  607. />
  608. )}
  609. </For>
  610. <For each={section.rows}>
  611. {(row) => {
  612. const best = () => (props.highlightBest ? bestDetailCellIndex(row) : undefined)
  613. return (
  614. <>
  615. <div data-slot="compare-detail-label">{row.label}</div>
  616. <For each={row.cells}>
  617. {(cell, index) => (
  618. <ComparisonDetailValue
  619. best={best() === index()}
  620. cell={cell}
  621. column={index()}
  622. last={index() === props.modelCount - 1}
  623. />
  624. )}
  625. </For>
  626. </>
  627. )
  628. }}
  629. </For>
  630. <Show when={section.usage}>
  631. {(usage) => (
  632. <>
  633. <div data-slot="compare-detail-label" data-empty="true" />
  634. <For each={usage()}>
  635. {(modelUsage, index) => (
  636. <ComparisonUsageBars
  637. data={modelUsage}
  638. column={index()}
  639. last={index() === props.modelCount - 1}
  640. />
  641. )}
  642. </For>
  643. </>
  644. )}
  645. </Show>
  646. <ComparisonDetailSpacer modelCount={props.modelCount} />
  647. </section>
  648. )}
  649. </For>
  650. </div>
  651. </section>
  652. )
  653. }
  654. function ComparisonDetailSpacer(props: { modelCount: number }) {
  655. return (
  656. <>
  657. <div data-slot="compare-detail-label" data-spacer="true" aria-hidden="true" />
  658. <For each={Array.from({ length: props.modelCount })}>
  659. {(_, index) => (
  660. <div
  661. data-slot="compare-detail-value"
  662. data-column={index()}
  663. data-last={index() === props.modelCount - 1 ? "true" : undefined}
  664. data-spacer="true"
  665. aria-hidden="true"
  666. />
  667. )}
  668. </For>
  669. </>
  670. )
  671. }
  672. function ComparisonDetailValue(props: { cell: ComparisonDetailCell; best: boolean; column: number; last: boolean }) {
  673. return (
  674. <div
  675. data-slot="compare-detail-value"
  676. data-column={props.column}
  677. data-last={props.last ? "true" : undefined}
  678. data-best={props.best ? "true" : undefined}
  679. >
  680. <Show when={props.cell.href} fallback={<ComparisonCellContent cell={props.cell} />}>
  681. {(href) => (
  682. <a href={href()} data-slot="compare-detail-value-link">
  683. <ComparisonCellContent cell={props.cell} />
  684. </a>
  685. )}
  686. </Show>
  687. </div>
  688. )
  689. }
  690. function ComparisonCellContent(props: { cell: ComparisonDetailCell }) {
  691. return (
  692. <Show
  693. when={props.cell.kind === "boolean"}
  694. fallback={
  695. <span data-slot="compare-detail-value-main">
  696. <span>{props.cell.value}</span>
  697. <Show when={props.cell.unit}>{(unit) => <span data-slot="compare-detail-unit">{unit()}</span>}</Show>
  698. <Show when={props.cell.trend !== undefined}>
  699. <span
  700. data-slot="compare-detail-trend"
  701. data-trend={
  702. props.cell.trend && props.cell.trend > 0
  703. ? "up"
  704. : props.cell.trend && props.cell.trend < 0
  705. ? "down"
  706. : "flat"
  707. }
  708. >
  709. {props.cell.trend && props.cell.trend > 0 ? "+" : ""}
  710. {formatPercent(props.cell.trend ?? 0)}
  711. </span>
  712. </Show>
  713. </span>
  714. }
  715. >
  716. <span data-slot="compare-detail-boolean" data-value={props.cell.value.toLowerCase()}>
  717. {props.cell.value}
  718. </span>
  719. </Show>
  720. )
  721. }
  722. function ComparisonUsageBars(props: { data: ModelUsagePoint[]; column: number; last: boolean }) {
  723. const points = () => props.data.slice(-usageBarLimit)
  724. const max = () => Math.max(...points().map((point) => point.tokens), 0)
  725. return (
  726. <div
  727. data-slot="compare-detail-value"
  728. data-column={props.column}
  729. data-last={props.last ? "true" : undefined}
  730. data-chart="true"
  731. >
  732. <Show
  733. when={points().length > 0 && max() > 0}
  734. fallback={<span data-slot="compare-detail-no-chart">No trend data</span>}
  735. >
  736. <span data-slot="compare-detail-bars" aria-hidden="true">
  737. <For each={points()}>
  738. {(point) => <i style={{ height: `${Math.max(4, Math.round((point.tokens / max()) * 40))}px` }} />}
  739. </For>
  740. </span>
  741. <span data-slot="compare-detail-bar-dates">
  742. <span>{formatUsageDate(points()[0]?.date)}</span>
  743. <span>{formatUsageDate(points()[points().length - 1]?.date)}</span>
  744. </span>
  745. </Show>
  746. </div>
  747. )
  748. }
  749. function ModelAvatar(props: { model: ModelCatalogEntry; size: "large" | "small" | "tiny" }) {
  750. return <LabLogo lab={props.model.lab} label={props.model.name} size={props.size} />
  751. }
  752. function LabLogo(props: { lab: string; label: string; size: "large" | "small" | "tiny" }) {
  753. const iconId = () => getProviderIconId(props.lab)
  754. return (
  755. <span data-slot="compare-home-avatar" data-lab={iconId()} data-size={props.size} aria-label={props.label}>
  756. <ProviderIcon aria-hidden="true" id={iconId()} />
  757. </span>
  758. )
  759. }
  760. function resolvedCatalogEntry(catalog: ModelCatalog | undefined, lab: string, model: string) {
  761. if (!catalog) return undefined
  762. return findModelCatalogEntry(catalog, model, lab) ?? null
  763. }
  764. function parseAdditionalModels(value: string | undefined) {
  765. return (value ?? "")
  766. .split(",")
  767. .flatMap((entry) => {
  768. const parts = entry.split("/")
  769. if (parts.length !== 2) return []
  770. const lab = catalogSlug(parts[0])
  771. const slug = catalogSlug(parts[1])
  772. return lab && slug ? [{ lab, slug }] : []
  773. })
  774. .slice(0, comparisonModelLimit - 2)
  775. }
  776. function comparisonModelSelectionKey(model: ComparisonModelSelection) {
  777. return model.catalog?.id ?? `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`
  778. }
  779. function comparisonModelsHref(models: ModelCatalogEntry[]) {
  780. const path = comparisonHref(modelRefFromCatalog(models[0]), modelRefFromCatalog(models[1]))
  781. const additional = models.slice(2).map((model) => `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`)
  782. return additional.length === 0 ? path : `${path}?add=${additional.join(",")}`
  783. }
  784. function comparisonDetailGridTemplate(modelCount: number) {
  785. return `minmax(0, var(--compare-detail-label-column)) repeat(${modelCount}, minmax(var(--compare-detail-model-column-min), 1fr))`
  786. }
  787. function buildComparisonModel(
  788. labParam: string,
  789. modelParam: string,
  790. catalog: ModelCatalogEntry | null,
  791. stats: StatsModelComparisonEntry | null,
  792. ): ComparisonModel {
  793. return {
  794. name: catalog?.name ?? stats?.model ?? formatParamName(modelParam),
  795. lab: catalog?.lab ?? stats?.provider ?? catalogSlug(labParam),
  796. labName: formatCatalogLabName(catalog?.lab ?? stats?.provider ?? labParam),
  797. slug: catalog?.slug ?? stats?.slug ?? catalogSlug(modelParam),
  798. catalog,
  799. stats,
  800. }
  801. }
  802. function comparisonCatalogEntry(model: ComparisonModel): ModelCatalogEntry {
  803. if (model.catalog) return model.catalog
  804. return {
  805. id: `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`,
  806. lab: catalogSlug(model.lab),
  807. slug: catalogSlug(model.slug),
  808. name: model.name,
  809. modalities: { input: [], output: [] },
  810. openWeights: false,
  811. reasoning: false,
  812. toolCall: false,
  813. attachment: false,
  814. temperature: false,
  815. weights: [],
  816. benchmarks: [],
  817. }
  818. }
  819. function uniqueCatalogModels(models: ModelCatalogEntry[]) {
  820. return Object.values(
  821. models.reduce<Record<string, ModelCatalogEntry>>((result, model) => {
  822. result[model.id] = result[model.id] ?? model
  823. return result
  824. }, {}),
  825. )
  826. }
  827. function buildComparisonDetailSections(models: readonly ComparisonModel[]): ComparisonDetailSection[] {
  828. return [
  829. {
  830. title: "Overview",
  831. rows: [
  832. comparisonDetailRow(
  833. "Author",
  834. models.map((model) => linkedTextCell(model.stats?.author ?? model.labName, labHref(model.lab))),
  835. ),
  836. comparisonDetailRow(
  837. "Context length",
  838. models.map((model) => limitCell(model.catalog?.limit?.context)),
  839. "higher",
  840. ),
  841. comparisonDetailRow(
  842. "Reasoning",
  843. models.map((model) => booleanDetailCell(model.catalog?.reasoning)),
  844. "higher",
  845. ),
  846. comparisonDetailRow(
  847. "Input modalities",
  848. models.map((model) => textCell(formatCatalogModalities(model.catalog?.modalities.input ?? []))),
  849. ),
  850. comparisonDetailRow(
  851. "Output modalities",
  852. models.map((model) => textCell(formatCatalogModalities(model.catalog?.modalities.output ?? []))),
  853. ),
  854. comparisonDetailRow(
  855. "Providers",
  856. models.map((model) => linkedTextCell(model.labName, labHref(model.lab))),
  857. ),
  858. ],
  859. },
  860. {
  861. title: "Pricing",
  862. rows: [
  863. comparisonDetailRow(
  864. "Input",
  865. models.map((model) => priceCell(model.catalog?.cost?.input)),
  866. "lower",
  867. ),
  868. comparisonDetailRow(
  869. "Output",
  870. models.map((model) => priceCell(model.catalog?.cost?.output)),
  871. "lower",
  872. ),
  873. comparisonDetailRow(
  874. "Cached input",
  875. models.map((model) => priceCell(model.catalog?.cost?.cacheRead)),
  876. "lower",
  877. ),
  878. ],
  879. },
  880. {
  881. title: "Momentum",
  882. badge: "Last 2 mo",
  883. rows: [
  884. comparisonDetailRow(
  885. "Unique users",
  886. models.map((model) => usageMetricCell(model.stats?.totals.uniqueUsers)),
  887. "higher",
  888. ),
  889. comparisonDetailRow(
  890. "Completed sessions",
  891. models.map((model) => usageMetricCell(model.stats?.totals.sessions, "integer")),
  892. "higher",
  893. ),
  894. comparisonDetailRow(
  895. "Token share",
  896. models.map((model) => percentCell(model.stats?.tokenShare)),
  897. "higher",
  898. ),
  899. comparisonDetailRow(
  900. "Tokens",
  901. models.map((model) => tokenCell(model.stats?.totals.tokens, model.stats?.tokenChange)),
  902. "higher",
  903. ),
  904. ],
  905. usage: models.map((model) => model.stats?.usage ?? []),
  906. },
  907. ]
  908. }
  909. function comparisonDetailRow(
  910. label: string,
  911. cells: ComparisonDetailCell[],
  912. direction?: ComparisonDirection,
  913. ): ComparisonDetailRow {
  914. return { label, direction, cells }
  915. }
  916. function bestDetailCellIndex(row: ComparisonDetailRow) {
  917. if (!row.direction) return undefined
  918. const scored = row.cells.flatMap((cell, index) => (cell.score === undefined ? [] : [{ index, score: cell.score }]))
  919. if (scored.length < 2) return undefined
  920. const score =
  921. row.direction === "higher"
  922. ? Math.max(...scored.map((cell) => cell.score))
  923. : Math.min(...scored.map((cell) => cell.score))
  924. const best = scored.filter((cell) => cell.score === score)
  925. return best.length === 1 ? best[0].index : undefined
  926. }
  927. function buildRelatedPairs(
  928. catalog: ModelCatalog | undefined,
  929. first: ComparisonModel,
  930. second: ComparisonModel,
  931. ): ComparisonPair[] {
  932. const current = [comparisonRef(first), comparisonRef(second)] as const
  933. const alternatives = (catalog?.models ?? [])
  934. .filter((model) => model.id !== first.catalog?.id && model.id !== second.catalog?.id)
  935. .slice(0, 4)
  936. .map(modelRefFromCatalog)
  937. return uniqueComparisonPairs([
  938. ...alternatives.slice(0, 3).flatMap((model, index) => [
  939. { first: current[0], second: model, detail: index === 0 ? "Nearby alternative" : "Related comparison" },
  940. { first: current[1], second: model, detail: index === 0 ? "Nearby alternative" : "Related comparison" },
  941. ]),
  942. ]).slice(0, 6)
  943. }
  944. function comparisonRef(model: ComparisonModel): ComparisonModelRef {
  945. return {
  946. name: model.name,
  947. lab: model.lab,
  948. slug: model.slug,
  949. labName: model.labName,
  950. metric: model.stats ? `#${model.stats.rank}` : "Catalog",
  951. }
  952. }
  953. function textCell(value: string): ComparisonDetailCell {
  954. return { value }
  955. }
  956. function linkedTextCell(value: string, href: string): ComparisonDetailCell {
  957. return { value, href }
  958. }
  959. function booleanDetailCell(value: boolean | undefined): ComparisonDetailCell {
  960. if (value === undefined) return { value: "Unknown" }
  961. return { value: value ? "True" : "False", kind: "boolean", score: value ? 1 : 0 }
  962. }
  963. function limitCell(value: number | undefined): ComparisonDetailCell {
  964. return value === undefined ? { value: "Unknown" } : { value: formatTokens(value), score: value }
  965. }
  966. function priceCell(value: number | undefined): ComparisonDetailCell {
  967. return value === undefined ? { value: "Unknown" } : { value: formatModelPrice(value), unit: "/ 1M", score: value }
  968. }
  969. function usageMetricCell(value: number | undefined, format: "compact" | "integer" = "compact"): ComparisonDetailCell {
  970. if (value === undefined) return { value: "No usage" }
  971. return { value: format === "integer" ? formatInteger(value) : formatTokens(value), score: value }
  972. }
  973. function percentCell(value: number | undefined): ComparisonDetailCell {
  974. return value === undefined ? { value: "No usage" } : { value: formatPercent(value), score: value }
  975. }
  976. function tokenCell(value: number | undefined, trend: number | undefined): ComparisonDetailCell {
  977. if (value === undefined) return { value: "No usage" }
  978. return { value: formatTokens(value), score: value, trend }
  979. }
  980. function labHref(lab: string) {
  981. return `${import.meta.env.BASE_URL}${catalogSlug(lab)}`
  982. }
  983. function modelSearchText(model: ModelCatalogEntry) {
  984. return `${model.name} ${formatCatalogLabName(model.lab)} ${model.id}`.toLowerCase()
  985. }
  986. function isFreeModel(model: ModelCatalogEntry) {
  987. return model.cost?.input === 0 && model.cost.output === 0
  988. }
  989. function modelHref(model: ModelCatalogEntry) {
  990. return `${import.meta.env.BASE_URL}${model.lab}/${model.slug}`
  991. }
  992. function formatModelUrl(model: ModelCatalogEntry) {
  993. return `.../${model.slug}`
  994. }
  995. function formatParamName(value: string) {
  996. return value
  997. .replace(/[-_]/g, " ")
  998. .replace(/\b\w/g, (letter) => letter.toUpperCase())
  999. .trim()
  1000. }
  1001. function formatCatalogLimit(value: number | undefined) {
  1002. return value === undefined ? "Unknown" : formatTokens(value)
  1003. }
  1004. function formatCatalogUnitPrice(value: number | undefined) {
  1005. if (value === undefined) return "Unknown"
  1006. return `${formatModelPrice(value)} / 1M`
  1007. }
  1008. function formatModelPrice(value: number) {
  1009. if (value > 0 && value < 0.01) return `$${value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "")}`
  1010. return formatMoney(value)
  1011. }
  1012. function formatCatalogModalities(values: string[]) {
  1013. if (values.length === 0) return "Unknown"
  1014. return values
  1015. .map((value) => value.replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()))
  1016. .join(", ")
  1017. }
  1018. function formatCatalogDate(value: string | undefined) {
  1019. if (!value) return "Unknown"
  1020. const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value)
  1021. if (!match) return value
  1022. const year = Number(match[1])
  1023. const month = match[2] ? Number(match[2]) - 1 : 0
  1024. const day = match[3] ? Number(match[3]) : 1
  1025. return new Intl.DateTimeFormat("en", {
  1026. month: match[2] ? "short" : undefined,
  1027. day: match[3] ? "numeric" : undefined,
  1028. year: "numeric",
  1029. timeZone: "UTC",
  1030. }).format(new Date(Date.UTC(year, month, day)))
  1031. }
  1032. function formatUsageDate(value: string | undefined) {
  1033. if (!value) return ""
  1034. const date = new Date(value)
  1035. if (Number.isNaN(date.getTime())) return value
  1036. return new Intl.DateTimeFormat("en", { month: "short", day: "numeric", timeZone: "UTC" }).format(date).toUpperCase()
  1037. }
  1038. function formatTokens(value: number) {
  1039. if (value >= 1_000_000_000_000)
  1040. return `${trimNumber(value / 1_000_000_000_000, value >= 10_000_000_000_000 ? 0 : 1)}T`
  1041. if (value >= 1_000_000_000) return `${trimNumber(value / 1_000_000_000, value >= 10_000_000_000 ? 0 : 1)}B`
  1042. if (value >= 1_000_000) return `${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M`
  1043. if (value >= 1_000) return `${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K`
  1044. return String(Math.round(value))
  1045. }
  1046. function formatInteger(value: number) {
  1047. return new Intl.NumberFormat("en").format(value)
  1048. }
  1049. function formatPercent(value: number) {
  1050. return `${trimNumber(value, Math.abs(value) >= 10 ? 1 : 2)}%`
  1051. }
  1052. function formatMoney(value: number) {
  1053. if (value >= 1_000_000) return `$${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M`
  1054. if (value >= 1_000) return `$${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K`
  1055. return `$${value.toFixed(value >= 10 ? 0 : 2)}`
  1056. }
  1057. function trimNumber(value: number, digits: number) {
  1058. return Number(value.toFixed(digits)).toLocaleString("en")
  1059. }
  1060. function getProviderIconId(provider: string) {
  1061. const id = provider.toLowerCase().replace(/[^a-z0-9]+/g, "")
  1062. if (id === "moonshot") return "moonshotai"
  1063. if (id === "zhipu") return "zhipuai"
  1064. return id
  1065. }
  1066. function ChevronDownIcon() {
  1067. return (
  1068. <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" fill="none">
  1069. <path d="M4.75 6.25L8 9.5L11.25 6.25" stroke="currentColor" stroke-width="1.5" />
  1070. </svg>
  1071. )
  1072. }